feat(core): Add bindScopeToEmitter to bind a scope to an event emitter#21594
feat(core): Add bindScopeToEmitter to bind a scope to an event emitter#21594mydea wants to merge 8 commits into
bindScopeToEmitter to bind a scope to an event emitter#21594Conversation
size-limit report 📦
|
60891cf to
03ee2c5
Compare
JPeer264
left a comment
There was a problem hiding this comment.
q: It almost looks like an 1:1 copy from the following just that it lives in core instead of opentelemetry. Is this intended? If yes, maybe it would make sense to consolidate those two functions.
| }; | ||
| } | ||
|
|
||
| function patchRemoveListener(ee: EventEmitterLike, original: BoundListener): BoundListener { |
There was a problem hiding this comment.
m: In patchRemoveAllListeners we are removing the event from the map. In this function we keep the map as is, is this difference intended?
There was a problem hiding this comment.
Added comments to explain this, here is the AI summary of why this is expected:
es, that difference is intentional and load-bearing — it's not an oversight.
The map is Map<event, WeakMap<userListener, boundWrapper>> (outer keyed by event-name string, inner keyed weakly by the user's listener).
Why patchRemoveListener must keep the entry: the whole design reuses one stable wrapper per listener (patchAddListener, lines 125–131), and Node's EventEmitter allows and counts
duplicate registrations. So a single user listener can be registered multiple times, all sharing the same wrapper B:
ee.on('data', fn); // registers wrapper B (map[data][fn] = B)
ee.on('data', fn); // registers B again — Node now has 2 copies of B
ee.removeListener('data', fn); // removes ONE copy of B
If removeListener deleted map[data][fn] here, the second still-registered copy of B would become orphaned: the next removeListener('data', fn) would no longer find B in the map, fall
through to original.apply(this, args) with the raw fn (line 149–150), and fail to match/remove the wrapper. So the mapping has to survive a single removal because other registrations of
the same wrapper may still be live.
Why patchRemoveAllListeners can delete it: removeAllListeners(event) tears down every listener for that event in one shot, so no registration referencing those wrappers remains. The map
entry is then unambiguously stale, and deleting it is safe — and a genuine cleanup, because the outer map is a plain Map keyed by event-name strings, which would otherwise accumulate
those keys forever. (The no-arg case resets the whole map, lines 162–164.)
Why leaving stale inner entries after removeListener isn't a leak: the inner structure is a WeakMap keyed by the user's listener, so once the user drops their reference to fn, the entry
is GC'd on its own. There's nothing to manually clean up there, and reusing the same wrapper if fn is re-added later is actually desirable (it preserves DOM EventTarget's (type,
callback, capture) dedup, per the comment on lines 6–14).
So: keep-on-single-remove (correctness, due to possible duplicates) vs. delete-on-remove-all (safe cleanup of the strong outer map) is the right asymmetry.
| } | ||
|
|
||
| function createPatchMap(ee: EventEmitterLike): ListenerPatchMap { | ||
| const map = Object.create(null) as ListenerPatchMap; |
There was a problem hiding this comment.
l: Any reason why not a Map is used? Then we wouldn't need to use type assertions
There was a problem hiding this comment.
good point, this is nicer!
Yeah, it is more or less a port of this! we can possibly unify this, but I'd look at this in a follow up, because not 100% sure how this interops, could be a circular dependency - withScope etc. depend on the context manager, so using this in there may be... weird? but not 100% sure. |
Adds a new `bindScopeToEmitter(emitter, scope?)` API to core. It captures a scope (defaulting to the current scope) and patches the emitter's listener registration methods so that any listener added afterwards runs with that scope — and therefore the active span — active, even when it fires later in a different async context. This is useful when instrumenting APIs that hand back an event emitter (e.g. a streamed database query) whose `'data'`/`'error'`/`'end'` listeners would otherwise lose the trace context. It mirrors the event-emitter behavior of OpenTelemetry's `ContextManager.bind`, scoped to emitters only. Works with both Node.js `EventEmitter`s (`on`, `addListener`, ...) and DOM `EventTarget`s (`addEventListener`); the isolation scope is intentionally not captured as it is carried along by the active async context. Exported from all SDKs and covered by unit, node-integration and browser-integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the API on the npm packages only; CDN bundle size should not grow for a Node-oriented helper. Skip the browser integration test in bundle mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…copeToEmitter A listener can be registered for the same event more than once; each is an independent registration in Node's EventEmitter. Storing a single wrapper per (event, listener) meant later registrations overwrote earlier ones, so only the most recent wrapper could be removed via the original reference and earlier ones were orphaned. Keep a stack of wrappers and remove one per removeListener call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Minting a fresh wrapper on every registration broke DOM EventTarget semantics: addEventListener dedupes by (type, callback, capture), so distinct wrapper refs defeated that idempotency (the listener fired once per call) and capture-aware removal could drop the wrong wrapper. Reuse a single stable wrapper per listener and forward the caller's options unchanged: the DOM dedupes correctly and Node's EventEmitter still counts duplicate registrations (removable one-per-call). This also subsumes the earlier per-event wrapper stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
03ee2c5 to
50d7613
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 50d7613. Configure here.

Summary
Adds a new
bindScopeToEmitter(emitter, scope?)API to@sentry/core. It captures a scope (defaulting to the current scope) and patches the emitter's listener-registration methods so that any listener added afterwards runs with that scope — and therefore the active span — active, even when it fires later in a different async context.This is useful when instrumenting APIs that hand back an event emitter (e.g. a streamed database query) whose
'data'/'error'/'end'listeners would otherwise lose the trace context. It mirrors the event-emitter behavior of OpenTelemetry'sContextManager.bind, scoped to emitters only (the function-binding path of OTel'sbindis intentionally omitted — at any such call site you already hold the callback and can wrap it withwithScopedirectly).Behavior
EventEmitters (on,addListener,once,prependListener,prependOnceListener) and DOMEventTargets (addEventListener).removeListener/off/removeEventListener) are patched so removals via the original listener reference find the wrapped listener; theaddEventListeneroptionsargument is forwarded; non-function (EventListener-object) listeners pass through untouched.node:eventsimport).Surface
@sentry/core+ node / node-core / browser, and the platform/framework re-exporters).Note: I opted to not include this in CDN bundles right now, as this is really not that critical to browser stuff. We can always add it later if it seems necessary.
Tests
@sentry/core): 19 cases incl. explicit-scope arg and the DOMEventTarget/addEventListenerpath.public-api/bindScopeToEmitter): a listener firing in a fresh async context nests under the bound parent, with an unbound control on a separate trace.tracing/bindScopeToEmitter): a realEventTarget+dispatchEvent, verified on the ESM buildThe mysql instrumentation rewiring that consumes this API is kept on a separate branch and will follow once this lands.
🤖 Generated with Claude Code